昨天我們學會了參數化測試,用優雅的方式處理大量測試資料。今天要解決一個新挑戰:「如何測試依賴外部服務的程式碼?」
想像你有個寄送通知的功能,它會真的寄出 email。測試時,你不希望真的寄信出去。這時候就需要「測試替身」來幫忙了!
今天結束後,你將學會:
unittest.mock
的用法第一階段:打好基礎(Day 1-10)
├── Day 01 - 環境設置與第一個測試
├── Day 02 - 認識斷言(Assertions)
├── Day 03 - TDD 紅綠重構循環
├── Day 04 - 測試結構與組織
├── Day 05 - 測試生命週期
├── Day 06 - 參數化測試
├── Day 07 - 測試替身基礎 ★ 今天在這裡
├── ...
└── (更多精彩內容待續)
測試替身(Test Double)就像電影中的替身演員,在測試時代替真實的依賴物件。
Stub(存根) - 提供固定回應
Mock(模擬) - 驗證互動行為
Spy(間諜) - 監控真實行為
先建立一個簡單的 EmailService 和 NotificationService。
建立 src/services/email_service.py
:
class EmailService:
def send(self, to: str, subject: str, body: str) -> bool:
# 實際實作會真的寄信
print(f"Sending email to {to}")
return True
建立 src/services/notification_service.py
:
from src.services.email_service import EmailService
class NotificationService:
def __init__(self, email_service: EmailService):
self.email_service = email_service
def notify(self, user_email: str, message: str) -> bool:
return self.email_service.send(
user_email,
'Notification',
message
)
建立 tests/day07/test_notification_service.py
:
from unittest.mock import Mock
from src.services.notification_service import NotificationService
def test_sends_email_when_notifying_user():
# 建立 Mock
mock_email_service = Mock()
mock_email_service.send.return_value = True
notification_service = NotificationService(mock_email_service)
# 執行測試
result = notification_service.notify('user@example.com', 'Hello!')
# 驗證結果
assert result is True
# 驗證 Mock 被正確呼叫
mock_email_service.send.assert_called_once_with(
'user@example.com',
'Notification',
'Hello!'
)
建立 src/services/random_generator.py
:
import random
class RandomGenerator:
def generate(self, min_val: int, max_val: int) -> int:
return random.randint(min_val, max_val)
建立 src/services/game_service.py
:
from src.services.random_generator import RandomGenerator
class GameService:
def __init__(self, random_generator: RandomGenerator):
self.random_generator = random_generator
def roll_dice(self) -> int:
return self.random_generator.generate(1, 6)
def is_winning(self, dice_value: int) -> bool:
return dice_value >= 4
建立 tests/day07/test_game_service.py
:
from unittest.mock import Mock
from src.services.game_service import GameService
def test_wins_when_dice_value_is_4_or_higher():
# 建立 Stub - 固定回傳 5
stub_random_generator = Mock()
stub_random_generator.generate.return_value = 5
game_service = GameService(stub_random_generator)
dice_value = game_service.roll_dice()
is_win = game_service.is_winning(dice_value)
assert dice_value == 5
assert is_win is True
def test_loses_when_dice_value_is_less_than_4():
# 建立 Stub - 固定回傳 2
stub_random_generator = Mock()
stub_random_generator.generate.return_value = 2
game_service = GameService(stub_random_generator)
dice_value = game_service.roll_dice()
is_win = game_service.is_winning(dice_value)
assert dice_value == 2
assert is_win is False
建立 src/services/logger.py
:
class Logger:
def log(self, message: str) -> None:
print(f"[LOG] {message}")
建立 src/services/calculator.py
:
from src.services.logger import Logger
class Calculator:
def __init__(self, logger: Logger):
self.logger = logger
def add(self, a: int, b: int) -> int:
result = a + b
self.logger.log(f"Adding {a} + {b} = {result}")
return result
def subtract(self, a: int, b: int) -> int:
result = a - b
self.logger.log(f"Subtracting {a} - {b} = {result}")
return result
建立 tests/day07/test_calculator_with_spy.py
:
from unittest.mock import Mock, patch
from src.services.calculator import Calculator
from src.services.logger import Logger
def test_logs_calculation_when_adding():
# 使用 Spy 監控 log 方法
logger = Logger()
logger.log = Mock(wraps=logger.log)
calculator = Calculator(logger)
result = calculator.add(2, 3)
# 驗證計算結果
assert result == 5
# 驗證 log 被呼叫
logger.log.assert_called_once_with('Adding 2 + 3 = 5')
def test_logs_calculation_when_subtracting():
logger = Logger()
logger.log = Mock(wraps=logger.log)
calculator = Calculator(logger)
result = calculator.subtract(5, 3)
assert result == 2
logger.log.assert_called_once_with('Subtracting 5 - 3 = 2')
Python 還提供了 @patch
裝飾器,可以更優雅地建立測試替身:
from unittest.mock import patch
from src.services.notification_service import NotificationService
@patch('src.services.notification_service.EmailService')
def test_notification_with_patch(mock_email_class):
# 設定 mock 實例
mock_email_instance = mock_email_class.return_value
mock_email_instance.send.return_value = True
# 執行測試
notification_service = NotificationService(mock_email_instance)
result = notification_service.notify('test@example.com', 'Test message')
assert result is True
mock_email_instance.send.assert_called_once()
# ✅ 好的做法:清楚的測試意圖
def test_sends_notification_email():
mock_email = Mock()
mock_email.send.return_value = True
# ... 簡單明瞭的測試
# ❌ 避免:過度複雜的設置
def test_does_everything():
# 10 行的 mock 設置...
pass
# ✅ 好的做法:專注單一行為
def test_calls_email_service_with_correct_parameters():
# 只測試參數傳遞
pass
def test_returns_true_when_email_is_sent_successfully():
# 只測試回傳值
pass
# ✅ 好的做法:驗證重要的互動
mock_service.send.assert_called_once_with(expected_params)
# ❌ 避免:過度驗證
assert mock.method1.call_count == 1
assert mock.method2.call_count == 2
assert mock.method3.call_count == 3
# ... 太多不必要的驗證
今天我們學會了:
✅ 測試替身的三種類型
✅ unittest.mock 測試工具
Mock()
:建立 Mock 物件return_value
:設定回傳值assert_called_once_with()
:驗證呼叫@patch
:裝飾器模式✅ 實務應用
試著為以下 PaymentService
寫測試:
class PaymentService:
def __init__(self, gateway, logger):
self.gateway = gateway
self.logger = logger
def process_payment(self, amount: float) -> bool:
self.logger.log(f"Processing payment: ${amount}")
if amount <= 0:
self.logger.log('Invalid amount')
return False
result = self.gateway.charge(amount)
self.logger.log(f"Payment result: {'success' if result else 'failed'}")
return result
提示:
PaymentGateway
的 charge
方法Logger
的 log
方法明天我們將學習「例外處理測試」,了解如何測試錯誤情況! 🚀